#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import argparse
import json
import re
from typing import List, Dict, Any, Optional, Tuple

from docx import Document
from docx.oxml.table import CT_Tbl
from docx.oxml.text.paragraph import CT_P
from docx.table import _Cell, Table
from docx.text.paragraph import Paragraph


class Config:
    # 字段别名
    FIELD_ALIASES: Dict[str, List[str]] = {
        "test_purpose": ["测试目的", "目的", "测试目标"],
        "test_coverage": ["测试覆盖", "覆盖范围", "测试覆盖范围"],
        "preconditions": ["前提条件", "先决条件", "前置条件"],
        "test_phase": ["测试阶段", "阶段", "Phase"],
        "test_case_id": ["测试用例", "测试用例ID", "用例编号", "测试用例编号", "测试用例名称"],
        "executor_signature": [
            "测试人印刷体姓名", "测试执行人签字", "执行人签字", "测试人签字",
            "测试人签名", "测试人员签名", "测试人员签字", "测试人签字及日期"
        ]
    }

    # 步骤表头别名
    STEP_HEADER_ALIASES: Dict[str, List[str]] = {
        "step_number": ["步骤", "Step", "序号", "No.", "编号"],
        "step_description": ["输入/动作", "步骤描述", "操作步骤", "操作/动作", "测试步骤"],
        "expected_result": ["预期结果", "期望结果", "Expected Result"],
        "actual_result": ["实际结果", "执行结果", "Actual Result", "实际结果/附件", "执行记录"],
        "conclusion": ["结论", "是否通过", "结果", "判定", "测试结果", "通过/失败"],
        "step_coverage": ["测试覆盖", "覆盖编号", "覆盖项", "覆盖需求", "Traceability", "Test Coverage"]
    }

    # 通过/失败判断
    ONLY_TRUE_OR_NULL: bool = True
    ACTUAL_RESULT_AS_BOOL: bool = True
    CHECKED_CHARS: List[str] = ["☑", "✔", "✓", "√", "■", "☒"]
    PASS_WORDS: List[str] = ["通过", "合格", "是", "一致"]
    FAIL_WORDS: List[str] = ["失败", "不通过", "不合格", "否", "不一致"]

    # 过滤“伪步骤”的关键词
    NO_STEP_KEYWORDS: List[str] = [
        "偏差", "备注", "数据审核人签字","测试复核人签名、日期"
        "测试执行人签字", "前提条件", "测试阶段", "目的", "测试覆盖",
        "执行人签名/日期：", "见证人签名/日期：","审核人意见/签名/日期","数据审核人印刷体姓名"
    ]

    # 行内兜底正则
    INLINE_REGEX: Dict[str, str] = {
        "test_case_id": r"(?:测试用例|测试用例ID|用例编号|测试用例名称)\s*[:：]\s*([A-Za-z0-9_\-]+)",
        "test_phase": r"(?:测试阶段|阶段)\s*[:：]\s*([A-Za-z0-9_\-]+)",
        "preconditions": r"(?:前提条件|先决条件|前置条件)\s*[:：]\s*(.+)"
    }

    # 覆盖编号抽取（第四种步骤覆盖列）
    COVERAGE_TOKEN_RE = re.compile(r"\b[A-Za-z0-9_]+(?:[-_.:][A-Za-z0-9_]+)+\b")

    # 步骤编号样式
    STEP_NUM_PATTERN = re.compile(r"^\s*[（(]?\s*(\d{1,4})\s*[)）．.、。]?\s*$")


class TextUtils:
    @staticmethod
    def norm_text(s: str) -> str:
        if s is None:
            return ""
        s = s.replace("\xa0", " ").replace("\u3000", " ")
        s = re.sub(r"\s+", " ", s)
        return s.strip()

    @staticmethod
    def remove_label_punct(s: str) -> str:
        return TextUtils.norm_text(s).replace("：", ":")

    @staticmethod
    def starts_with_alias(cell_text: str, alias: str) -> bool:
        t = TextUtils.remove_label_punct(cell_text)
        a = TextUtils.remove_label_punct(alias)
        if not t.startswith(a):
            return False
        rest = t[len(a):]
        return (rest == "") or rest.startswith(":") or rest.startswith(" ")

    @staticmethod
    def contains_no_step_keywords(text: str, keywords: List[str]) -> bool:
        return any(k in text for k in keywords)

    @staticmethod
    def split_coverage_to_list(s: str) -> List[str]:
        s = TextUtils.norm_text(s)
        if not s:
            return []
        lines = []
        for ln in s.split("\n"):
            ln = ln.strip()
            if not ln:
                continue
            parts = re.split(r"[，,、；;]\s*", ln)
            for p in parts:
                p = p.strip()
                if p:
                    lines.append(p)
        return lines


class DocxWalker:
    @staticmethod
    def iter_block_items(parent):
        if isinstance(parent, _Cell):
            parent_elm = parent._tc
        else:
            parent_elm = parent.element.body
        for child in parent_elm.iterchildren():
            if isinstance(child, CT_P):
                yield Paragraph(child, parent)
            elif isinstance(child, CT_Tbl):
                yield Table(child, parent)

    @staticmethod
    def get_list_number_if_any(paragraph, counters: Dict[tuple, int]) -> Optional[int]:
        try:
            pPr = paragraph._p.pPr
            if pPr is None or pPr.numPr is None:
                return None
            numPr = pPr.numPr
            numId = int(numPr.numId.val)
            ilvl = int(numPr.ilvl.val) if numPr.ilvl is not None else 0
            key = (numId, ilvl)
            counters[key] = counters.get(key, 0) + 1
            return counters[key]
        except Exception:
            return None

    @staticmethod
    def clean_cell_with_numbering(cell, list_counters: Dict[tuple, int]) -> str:
        parts = []
        for p in cell.paragraphs:
            t = TextUtils.norm_text(p.text)
            n = DocxWalker.get_list_number_if_any(p, list_counters)
            if n is not None:
                if t:
                    parts.append(f"{n} {t}")
                else:
                    parts.append(str(n))
            elif t:
                parts.append(t)
        return "\n".join(parts).strip()

    @staticmethod
    def table_to_matrix(tbl) -> List[List[str]]:
        list_counters: Dict[tuple, int] = {}
        return [[DocxWalker.clean_cell_with_numbering(c, list_counters) for c in r.cells] for r in tbl.rows]

    @staticmethod
    def cell_text(cell: _Cell) -> str:
        txt = "\n".join(p.text for p in cell.paragraphs)
        return TextUtils.norm_text(txt)


class MetaExtractor:
    def __init__(self, cfg: Config):
        self.cfg = cfg

    def label_match_key(self, cell_text: str) -> Optional[str]:
        for key, aliases in self.cfg.FIELD_ALIASES.items():
            for a in aliases:
                if TextUtils.starts_with_alias(cell_text, a):
                    return key
        return None

    def extract_value_after_colon(self, text: str) -> Optional[str]:
        t = TextUtils.remove_label_punct(text)
        if ":" in t:
            after = t.split(":", 1)[1].strip()
            return after if after else None
        return None

    def looks_like_name(self, val: str) -> bool:
        v = TextUtils.norm_text(val)
        if not v:
            return False
        if any(k in v for k in ["日期", "签字", "签名", "备注", "测试", "通过", "失败", "是", "否", "一致"]):
            return False
        return bool(re.search(r"[A-Za-z\u4e00-\u9fa5·•.]+", v))

    def row_to_field_kv(self, row: List[str]) -> Dict[str, Any]:
        # 保持你最后的工作逻辑
        kv = {}
        i = 0
        while i < len(row):
            cell = row[i]
            key_name = self.label_match_key(cell)
            if key_name:
                val = self.extract_value_after_colon(cell)
                right = None
                if (i + 1) < len(row):
                    cand = TextUtils.norm_text(row[i + 1])
                    if cand and not self.label_match_key(cand):
                        right = cand

                if key_name == "executor_signature":
                    chosen = None
                    if right and self.looks_like_name(right):
                        chosen = right
                    elif val and self.looks_like_name(val):
                        chosen = val
                    elif (i + 2) < len(row):
                        cand2 = TextUtils.norm_text(row[i + 2])
                        if cand2 and not self.label_match_key(cand2) and self.looks_like_name(cand2):
                            chosen = cand2
                    if chosen:
                        kv[key_name] = chosen
                else:
                    v = val if val is not None else (right or "")
                    if key_name == "test_coverage":
                        arr = TextUtils.split_coverage_to_list(v)
                        kv[key_name] = (kv.get(key_name, []) + arr) if isinstance(kv.get(key_name), list) else arr
                    else:
                        kv[key_name] = v

                i += 2
                continue

            # 单格正则兜底
            for fld, pattern in self.cfg.INLINE_REGEX.items():
                m = re.search(pattern, TextUtils.remove_label_punct(cell), flags=re.IGNORECASE)
                if m:
                    val = m.group(1).strip()
                    if fld == "test_coverage":
                        arr = TextUtils.split_coverage_to_list(val)
                        kv[fld] = (kv.get(fld, []) + arr) if isinstance(kv.get(fld), list) else arr
                    else:
                        if fld not in kv or not kv[fld]:
                            kv[fld] = val
            i += 1
        return kv


class StepExtractor:
    def __init__(self, cfg: Config):
        self.cfg = cfg

    def header_score(self, row_texts: List[str]) -> int:
        score = 0
        groups = {
            "step_number": self.cfg.STEP_HEADER_ALIASES["step_number"],
            "step_description": self.cfg.STEP_HEADER_ALIASES["step_description"],
            "expected_result": self.cfg.STEP_HEADER_ALIASES["expected_result"],
            "actual_result": self.cfg.STEP_HEADER_ALIASES["actual_result"],
            "conclusion": self.cfg.STEP_HEADER_ALIASES["conclusion"],
            "step_coverage": self.cfg.STEP_HEADER_ALIASES["step_coverage"],
        }
        for aliases in groups.values():
            if any(any(a in cell for a in aliases) for cell in row_texts):
                score += 1
        return score

    def find_header_row(self, mat: List[List[str]]) -> Optional[int]:
        best_idx, best_score = None, 0
        for idx, row in enumerate(mat):
            sc = self.header_score(row)
            if sc > best_score:
                best_score = sc
                best_idx = idx
        return best_idx if best_score >= 3 else None

    def map_step_columns(self, header_row: List[str]) -> Dict[str, int]:
        col_map = {}
        for idx, h in enumerate(header_row):
            h_norm = h.replace("：", ":")
            for std, aliases in self.cfg.STEP_HEADER_ALIASES.items():
                for a in aliases:
                    if a == h_norm:  # 保持你“精确等于”的策略
                        col_map["conclusion" if std == "conclusion" else std] = idx
                        break
        if "step_number" not in col_map and header_row:
            col_map["step_number"] = 0
        if "step_description" not in col_map and len(header_row) >= 2:
            col_map["step_description"] = 1
        return col_map

    def parse_step_number(self, text: str) -> Optional[int]:
        t = TextUtils.norm_text(text)
        if not t:
            return None
        m = self.cfg.STEP_NUM_PATTERN.match(t)
        return int(m.group(1)) if m else None

    def get_cell_by_key(self, row: List[str], col_map: Dict[str, int], key: str) -> str:
        idx = col_map.get(key)
        if idx is None or idx >= len(row):
            return ""
        return TextUtils.norm_text(row[idx])

    def detect_digit_column(self, mat: List[List[str]], start_row: int) -> Optional[int]:
        if not mat or not mat[0]:
            return None
        cols = max(len(r) for r in mat)
        counts = [0] * cols
        for r in mat[start_row: start_row + 12]:
            for j in range(len(r)):
                if self.parse_step_number(r[j]) is not None:
                    counts[j] += 1
        return counts.index(max(counts)) if max(counts) > 0 else None

    def extract_coverage_codes(self, text: str) -> List[str]:
        t = TextUtils.norm_text(text)
        if not t:
            return []
        toks = self.cfg.COVERAGE_TOKEN_RE.findall(t)
        codes = []
        for tok in toks:
            if re.search(r"[A-Za-z]", tok) and re.search(r"\d", tok):
                codes.append(tok)
        return codes

    def parse_pass_state(self, text: str) -> Optional[bool]:
        t = TextUtils.norm_text(text)
        if not t:
            return None
        for ch in self.cfg.CHECKED_CHARS:
            for w in self.cfg.PASS_WORDS:
                if f"{ch}{w}" in t or f"{w}{ch}" in t:
                    return True
            if f"{ch}是" in t or f"是{ch}" in t:
                return True
        if not self.cfg.ONLY_TRUE_OR_NULL:
            for ch in self.cfg.CHECKED_CHARS:
                for w in self.cfg.FAIL_WORDS:
                    if f"{ch}{w}" in t or f"{w}{ch}" in t:
                        return False
                if f"{ch}否" in t or f"否{ch}" in t:
                    return False
        return None


class TestcaseParser:
    def __init__(self, cfg: Config):
        self.cfg = cfg
        self.meta = MetaExtractor(cfg)
        self.step = StepExtractor(cfg)

    # 判断是否为“用例抬头表”
    def is_case_header_table(self, table_kv: Dict[str, Any], header_row_idx: Optional[int]) -> bool:
        has_core_fields = ("test_purpose" in table_kv) or ("test_coverage" in table_kv) \
                          or ("test_phase" in table_kv) or ("preconditions" in table_kv)
        no_step_header = (header_row_idx is None)
        return has_core_fields and no_step_header

    def parse(self, path: str) -> List[Dict[str, Any]]:
        doc = Document(path)
        cases: List[Dict[str, Any]] = []
        current: Optional[Dict[str, Any]] = None

        def ensure_current():
            nonlocal current
            if current is None:
                current = {
                    "test_purpose": "", "test_coverage": [], "preconditions": "",
                    "test_phase": "", "test_case_id": "", "test_steps": [],
                    "executor_signature": ""
                }

        def has_content(case: Dict[str, Any]) -> bool:
            return any([
                case.get("test_purpose"), case.get("test_case_id"), case.get("test_phase"),
                case.get("executor_signature"), case.get("preconditions"),
                bool(case.get("test_coverage")), bool(case.get("test_steps"))
            ])

        def finalize_current():
            nonlocal current
            if current and has_content(current):
                # 去重覆盖项
                if isinstance(current.get("test_coverage"), list):
                    seen, uniq = set(), []
                    for item in current["test_coverage"]:
                        it = item.strip()
                        if it and it not in seen:
                            uniq.append(it); seen.add(it)
                    current["test_coverage"] = uniq
                # 清理步骤布尔
                for st in current.get("test_steps", []):
                    for k in ("is_passed", "actual_result"):
                        if k in st and st[k] not in (True, False, None):
                            st[k] = None
                cases.append(current)
            current = None

        for block in DocxWalker.iter_block_items(doc):
            # 段落兜底 test_case_id
            if isinstance(block, Paragraph):
                text = TextUtils.norm_text(block.text)
                if not text:
                    continue
                m = re.search(self.cfg.INLINE_REGEX["test_case_id"], TextUtils.remove_label_punct(text), flags=re.IGNORECASE)
                if m:
                    val = m.group(1).strip()
                    ensure_current()
                    if current["test_case_id"] and current["test_case_id"] != val and has_content(current):
                        finalize_current(); ensure_current()
                    current["test_case_id"] = val
                continue

            # 表格处理
            if isinstance(block, Table):
                mat = DocxWalker.table_to_matrix(block)
                if not mat:
                    continue

                # 探测是否为步骤表头
                header_row_idx = self.step.find_header_row(mat)

                # 1) 字段抽取（跳过步骤表头行，避免把“测试覆盖”列误当作键值对）
                table_kv: Dict[str, Any] = {}
                for ridx, row in enumerate(mat):
                    if header_row_idx is not None and ridx == header_row_idx:
                        continue
                    kv = self.meta.row_to_field_kv(row)
                    if not kv:
                        continue
                    for k, v in kv.items():
                        if k == "test_coverage":
                            table_kv[k] = (table_kv.get(k, []) + v) if isinstance(table_kv.get(k), list) else v
                        else:
                            if isinstance(v, list): v = " ".join(v)
                            if k not in table_kv or not table_kv[k]:
                                table_kv[k] = v

                # 单格正则兜底
                flat_cells = [TextUtils.remove_label_punct(x) for row in mat for x in row if x]
                for fld, pattern in self.cfg.INLINE_REGEX.items():
                    if table_kv.get(fld):
                        continue
                    for cell_text in flat_cells:
                        m = re.search(pattern, cell_text, flags=re.IGNORECASE)
                        if m:
                            table_kv[fld] = m.group(1).strip()
                            break

                # 2) 抬头表识别
                if self.is_case_header_table(table_kv, header_row_idx):
                    if current and (current.get("test_case_id") or current.get("test_steps")) and has_content(current):
                        finalize_current()
                    ensure_current()
                    for k, v in table_kv.items():
                        if k == "test_coverage":
                            current.setdefault("test_coverage", []).extend(v if isinstance(v, list) else [v])
                        else:
                            if isinstance(v, list): v = " ".join(v)
                            if not current.get(k):
                                current[k] = v
                    continue

                # 3) 普通合并元信息
                if table_kv.get("test_case_id"):
                    ensure_current()
                    new_id = table_kv["test_case_id"]
                    if current["test_case_id"] and current["test_case_id"] != new_id and has_content(current):
                        finalize_current(); ensure_current()
                    current["test_case_id"] = new_id

                if table_kv:
                    ensure_current()
                    for k, v in table_kv.items():
                        if k == "test_coverage":
                            current.setdefault("test_coverage", []).extend(v if isinstance(v, list) else [v])
                        elif k == "executor_signature":
                            if self.meta.looks_like_name(v) and not current.get("executor_signature"):
                                current["executor_signature"] = v
                        else:
                            if isinstance(v, list): v = " ".join(v)
                            if not current.get(k):
                                current[k] = v
                            elif not current[k] and v:
                                current[k] = v

                # 4) 步骤解析（仅步骤表）
                if header_row_idx is None:
                    continue

                header = mat[header_row_idx]
                col_map = self.step.map_step_columns(header)

                # 若无法定位编号列，则根据数字密集列猜测
                if "step_number" not in col_map or col_map["step_number"] >= len(header):
                    sn_guess = self.step.detect_digit_column(mat, header_row_idx + 1)
                    if sn_guess is not None:
                        col_map["step_number"] = sn_guess

                i = header_row_idx + 1
                n = len(mat)

                def row_is_footer(r: List[str]) -> bool:
                    joined = " ".join(TextUtils.norm_text(x) for x in r)
                    return TextUtils.contains_no_step_keywords(joined, self.cfg.NO_STEP_KEYWORDS)

                ensure_current()
                while i < n:
                    row = mat[i]
                    if all(not TextUtils.norm_text(x) for x in row):
                        i += 1; continue
                    if row_is_footer(row):
                        i += 1; continue

                    sn_col = col_map.get("step_number", 0)
                    cur_sn = self.step.parse_step_number(row[sn_col] if sn_col < len(row) else "")
                    cur_desc = self.step.get_cell_by_key(row, col_map, "step_description")
                    cur_exp  = self.step.get_cell_by_key(row, col_map, "expected_result")
                    cur_act  = self.step.get_cell_by_key(row, col_map, "actual_result")
                    cur_conc = self.step.get_cell_by_key(row, col_map, "conclusion")
                    cur_cov  = self.step.get_cell_by_key(row, col_map, "step_coverage")

                    # 从覆盖列中收集覆盖编号到顶层 test_coverage（第四种）
                    if cur_cov:
                        current.setdefault("test_coverage", []).extend(self.step.extract_coverage_codes(cur_cov))

                    if cur_sn is None:
                        i += 1
                        continue
                    if TextUtils.contains_no_step_keywords(cur_desc + cur_exp, self.cfg.NO_STEP_KEYWORDS):
                        i += 1
                        continue
                    if not (cur_desc or cur_exp):
                        if i - 1 >= header_row_idx + 1:
                            prev = mat[i - 1]
                            if not row_is_footer(prev):
                                prev_sn = self.step.parse_step_number(prev[sn_col] if sn_col < len(prev) else "")
                                if prev_sn is None:
                                    pdesc = self.step.get_cell_by_key(prev, col_map, "step_description")
                                    pexp  = self.step.get_cell_by_key(prev, col_map, "expected_result")
                                    if pdesc or pexp:
                                        cur_desc, cur_exp = pdesc, pexp
                                        cur_act  = " ".join([self.step.get_cell_by_key(prev, col_map, "actual_result"), cur_act]).strip()
                                        cur_conc = " ".join([self.step.get_cell_by_key(prev, col_map, "conclusion"), cur_conc]).strip()
                    if not (cur_desc or cur_exp):
                        i += 1
                        continue

                    merged = " ".join([cur_act, cur_conc])
                    pass_state = self.step.parse_pass_state(merged)
                    step_obj = {
                        "step_number": cur_sn,
                        "step_description": cur_desc,
                        "expected_result": cur_exp,
                        "actual_result": self.cfg.ACTUAL_RESULT_AS_BOOL and pass_state or merged,
                        "is_passed": pass_state
                    }
                    current["test_steps"].append(step_obj)
                    i += 1
                    continue

        if current:
            finalize_current()
        return cases


def main():
    parser = argparse.ArgumentParser(description="Parse .docx OQ/OQP Testcase to JSON (OOP version)")
    parser.add_argument("--input", "-i", required=True, help="docx file path")
    parser.add_argument("--output", "-o", default="", help="output json file, default print to stdout")
    args = parser.parse_args()

    cfg = Config()
    engine = TestcaseParser(cfg)
    cases = engine.parse(args.input)

    if args.output:
        with open(args.output, "w", encoding="utf-8") as f:
            json.dump(cases, f, ensure_ascii=False, indent=2)
        print(f"Saved {len(cases)} cases to {args.output}")
    else:
        print(json.dumps(cases, ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()